// Copyright 2016 Google, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
////////////////////////////////////////////////////////////////////////////////

package com.firebase.jobdispatcher;

import static com.firebase.jobdispatcher.GooglePlayReceiver.TAG;
import static com.firebase.jobdispatcher.RetryStrategy.RETRY_POLICY_EXPONENTIAL;
import static com.firebase.jobdispatcher.RetryStrategy.RETRY_POLICY_LINEAR;

import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.os.Bundle;
import android.os.Parcel;
import android.support.annotation.CallSuper;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.VisibleForTesting;
import android.util.Log;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;

/**
 * Validates Jobs according to some safe standards.
 *
 * <p>Custom JobValidators should typically extend from this.
 */
public class DefaultJobValidator implements JobValidator {

  /**
   * The maximum length of a tag, in characters (i.e. String.length()). Strings longer than this
   * will cause validation to fail.
   */
  public static final int MAX_TAG_LENGTH = 100;

  /**
   * The maximum size, in bytes, that the provided extras bundle can be. Corresponds to {@link
   * Parcel#dataSize()}.
   */
  public static final int MAX_EXTRAS_SIZE_BYTES = 10 * 1024;

  /** Private ref to the Context. Necessary to check that the manifest is configured correctly. */
  private final Context context;

  public DefaultJobValidator(@NonNull Context context) {
    this.context = context;
  }

  /** @see #MAX_EXTRAS_SIZE_BYTES */
  private static int measureBundleSize(Bundle extras) {
    Parcel p = Parcel.obtain();
    extras.writeToParcel(p, 0);
    int sizeInBytes = p.dataSize();
    p.recycle();

    return sizeInBytes;
  }

  /** Combines two {@literal List<String>s} together. */
  @Nullable
  private static List<String> mergeErrorLists(
      @Nullable List<String> errors, @Nullable List<String> newErrors) {
    if (errors == null) {
      return newErrors;
    }
    if (newErrors == null) {
      return errors;
    }

    errors.addAll(newErrors);
    return errors;
  }

  @Nullable
  private static List<String> addError(@Nullable List<String> errors, String newError) {
    if (newError == null) {
      return errors;
    }
    if (errors == null) {
      return getMutableSingletonList(newError);
    }

    Collections.addAll(errors, newError);

    return errors;
  }

  @Nullable
  private static List<String> addErrorsIf(boolean condition, List<String> errors, String newErr) {
    if (condition) {
      return addError(errors, newErr);
    }

    return errors;
  }

  /**
   * Attempts to validate the provided {@code JobParameters}. If the JobParameters is valid, null
   * will be returned. If the JobParameters has errors, a list of those errors will be returned.
   */
  @Nullable
  @Override
  @CallSuper
  public List<String> validate(@NonNull JobParameters job) {
    List<String> errors;

    errors = mergeErrorLists(null, validate(job.getTrigger()));
    errors = mergeErrorLists(errors, validate(job.getRetryStrategy()));

    if (job.isRecurring() && job.getTrigger() == Trigger.NOW) {
      errors = addError(errors, "ImmediateTriggers can't be used with recurring jobs");
    }

    errors = mergeErrorLists(errors, validateForTransport(job.getExtras()));
    if (job.getLifetime() > Lifetime.UNTIL_NEXT_BOOT) {
      // noinspection ConstantConditions
      errors = mergeErrorLists(errors, validateForPersistence(job.getExtras()));
    }

    errors = mergeErrorLists(errors, validateTag(job.getTag()));
    errors = mergeErrorLists(errors, validateService(job.getService()));

    return errors;
  }

  /**
   * Attempts to validate the provided Trigger. If valid, null is returned. Otherwise a list of
   * errors will be returned.
   *
   * <p>Note that a Trigger that passes validation here is not necessarily valid in all permutations
   * of a JobParameters. For example, an Immediate is never valid for a recurring job.
   */
  @Nullable
  @Override
  @CallSuper
  public List<String> validate(@NonNull JobTrigger trigger) {
    if (trigger != Trigger.NOW
        && !(trigger instanceof JobTrigger.ExecutionWindowTrigger)
        && !(trigger instanceof JobTrigger.ContentUriTrigger)) {
      return getMutableSingletonList("Unknown trigger provided");
    }

    return null;
  }

  /**
   * Attempts to validate the provided RetryStrategy. If valid, null is returned. Otherwise a list
   * of errors will be returned.
   */
  @Nullable
  @Override
  @CallSuper
  public List<String> validate(@NonNull RetryStrategy retryStrategy) {
    List<String> errors;

    int policy = retryStrategy.getPolicy();
    int initial = retryStrategy.getInitialBackoff();
    int maximum = retryStrategy.getMaximumBackoff();

    errors =
        addErrorsIf(
            policy != RETRY_POLICY_EXPONENTIAL && policy != RETRY_POLICY_LINEAR,
            null,
            "Unknown retry policy provided");
    errors =
        addErrorsIf(
            maximum < initial,
            errors,
            "Maximum backoff must be greater than or equal to initial backoff");
    errors =
        addErrorsIf(300 > maximum, errors, "Maximum backoff must be greater than 300s (5 minutes)");
    errors = addErrorsIf(initial < 30, errors, "Initial backoff must be at least 30s");

    return errors;
  }

  @Nullable
  private static List<String> validateForPersistence(@Nullable Bundle extras) {
    List<String> errors = null;

    if (extras != null) {
      // check the types to make sure they're persistable
      for (String k : extras.keySet()) {
        errors = addError(errors, validateExtrasType(extras, k));
      }
    }

    return errors;
  }

  @Nullable
  private static List<String> validateForTransport(@Nullable Bundle extras) {
    if (extras == null) {
      return null;
    }

    int bundleSizeInBytes = measureBundleSize(extras);
    if (bundleSizeInBytes > MAX_EXTRAS_SIZE_BYTES) {
      return getMutableSingletonList(
          String.format(
              Locale.US,
              "Extras too large: %d bytes is > the max (%d bytes)",
              bundleSizeInBytes,
              MAX_EXTRAS_SIZE_BYTES));
    }

    return null;
  }

  @Nullable
  private static String validateExtrasType(@NonNull Bundle extras, @NonNull String key) {
    Object o = extras.get(key);

    if (o == null
        || o instanceof Integer
        || o instanceof Long
        || o instanceof Double
        || o instanceof String
        || o instanceof Boolean) {
      return null;
    }

    return String.format(
        Locale.US,
        "Received value of type '%s' for key '%s', but only the"
            + " following extra parameter types are supported:"
            + " Integer, Long, Double, String, and Boolean",
        o == null ? null : o.getClass(),
        key);
  }

  @VisibleForTesting
  List<String> validateService(String service) {
    if (service == null || service.isEmpty()) {
      return getMutableSingletonList("Service can't be empty");
    }

    if (context == null) {
      return getMutableSingletonList("Context is null, can't query PackageManager");
    }

    PackageManager pm = context.getPackageManager();
    if (pm == null) {
      return getMutableSingletonList("PackageManager is null, can't validate service");
    }

    Intent executeIntent = new Intent(JobService.ACTION_EXECUTE);
    executeIntent.setClassName(context, service);
    List<ResolveInfo> intentServices = pm.queryIntentServices(executeIntent, 0);
    if (intentServices == null || intentServices.isEmpty()) {
      // queryIntentServices might incorrectly return an empty list.
      String msg =
          "Couldn't find a registered service with the name "
              + service
              + ". Is it declared in the manifest with the right intent-filter?"
              + " If not, the job won't be started.";
      Log.e(TAG, msg);
      return null;
    }

    for (ResolveInfo info : intentServices) {
      if (info.serviceInfo != null && info.serviceInfo.enabled) {
        // found a match!
        return null;
      }
    }

    return getMutableSingletonList(service + " is disabled.");
  }

  private static List<String> validateTag(String tag) {
    if (tag == null) {
      return getMutableSingletonList("Tag can't be null");
    }

    if (tag.length() > MAX_TAG_LENGTH) {
      return getMutableSingletonList("Tag must be shorter than " + MAX_TAG_LENGTH);
    }

    return null;
  }

  @NonNull
  private static List<String> getMutableSingletonList(String msg) {
    ArrayList<String> strings = new ArrayList<>();
    strings.add(msg);
    return strings;
  }
}
